iT邦幫忙

2024 iThome 鐵人賽

DAY 6
0
Software Development

Django 2024: 從入門到SaaS實戰系列 第 7

Django in 2024: 解鎖Django form的潛力

  • 分享至 

  • xImage
  •  

用一般HTML中的表單來添加數據可能各位都不陌生,不外乎就是HTML提供用戶輸入數據,驗證數據是否符合需求,最後進行增刪改查等操作。而Django中表單功能由Form對象所實現,除了django.forms.Form之外,還有基於model之上的django.forms.ModelForm來更加完善表單的功能

今日的文章重點如下:

  • 使用django.forms.From建立表單
  • 使用django.forms.ModelFrom建立表單
  • 了解forms.widgets的魅力,你看到的表單不是實際的表單

那就直接開始吧~

程式碼:https://github.com/class83108/django_project/tree/hello_world

使用django.forms.From建立表單

  1. 先建立froms.py以及相關邏輯
# article app下建立forms.py
from django import forms
from .models import Category, Author, Tag

class ArticleForm(forms.Form):
    title = forms.CharField(max_length=120)
    content = forms.CharField(widget=forms.Textarea)
    category = forms.ModelChoiceField(queryset=Category.objects.all())
    author = forms.ModelChoiceField(queryset=Author.objects.all())
    tags = forms.ModelMultipleChoiceField(queryset=Tag.objects.all())
    
# urls.py
urlpatterns = [
    path("demo_form/", demo_form_view, name="demo_form_view"),
    path("article_list/", article_list_view, name="article_list_view"),
    path(
        "article_detail/<int:article_id>/",
        article_detail_view,
        name="article_detail_view",
    ),
    ....
]

# views.py
def article_list_view(request):
    articles = Article.objects.all()
    return render(request, "article_list.html", {"articles": articles})

def article_detail_view(request, article_id):
    article = Article.objects.get(article_id=article_id)
    return render(request, "article_detail.html", {"article": article})

def demo_form_view(request):
    article_form = ArticleForm()
    return render(request, "demo_form.html", locals())

其中forms的欄位在官方文檔中有詳細的說明,可以參考以下網址:

https://docs.djangoproject.com/en/5.1/ref/forms/fields/#built-in-field-classes

  1. 建立模板
# demo_form.html
{% extends "base.html" %}
{% block content %}

{% if article_form.errors %}
	<div>
		表單數據有誤:{{ article_form.errors }}
	</div>
{% else %}
<form action="{% url "article:demo_form_view" %}" method="post">
	{% csrf_token %}
	<table>
		{{ article_form.as_table }}
	</table>
	<input type="submit" name="" id="" value="Save">
</form>
{% endif %}

{% endblock content %}

https://ithelp.ithome.com.tw/upload/images/20240918/20161866IRYZpcypWB.png
可以去頁面看一下,雖然很陽春,並且在category、Author還有Tags的選項都不是很直觀,但是我們之後再修改。看一下模板本身的程式碼

{{ article_form.as_table }}
{{ article_form.as_p }}
{{ article_form.as_div }}
{{ article_form.as_ul }}

可以用不同的方法來顯示出表單的HTML進而減少Template需要寫的程式碼量

也可以用以下方式寫,更符合Bootstrap的格式,在不增加太多程式碼的情況下保持一定的彈性

並且也能針對不同的欄位做不同的調整

{% for field in article_form %}
        <div class="form-group ">
            <div class="row">
                <label class="col-3" for="{{ field.id_for_label }}">{{field.label}}
                    {% if field.field.required %}
                        <span class="text-red">*</span>
                    {% endif %}
                </label>
                <div class="col-9">
					{{ field }}
					{% if field.errors %}
						<div class="text-red">{{ field.errors }}</div>
					{% endif %}
				</div>
			</div>
		</div>
	{% endfor %}
  1. 回去修改一下驗證邏輯
def demo_form_view(request):
    article_form = ArticleForm()
    error = None
    if request.method == "POST":
        article_form = ArticleForm(request.POST)
        if article_form.is_valid():
            title = article_form.cleaned_data["title"]
            content = article_form.cleaned_data["content"]
            category = article_form.cleaned_data["category"]
            author = article_form.cleaned_data["author"]
            tags = article_form.cleaned_data["tags"]

            article = Article.objects.create(
                title=title, content=content, category=category, author=author
            )
            article.tags.set(tags)
            return redirect("article:article_list_view")
        else:
            error = article_form.errors

    return render(request, "demo_form.html", locals())

當確定為Post請求後,根據post的資料建立出ArticleForm對象,接著用is_valid()做數據的驗證

  • is_valid() 方法的目的:
    • 驗證表單中的所有數據是否符合預定義的規則
    • 將原始的表單數據轉換為 Python 對象
  • 驗證過程:
    當調用 is_valid() 時,Django 會執行以下步驟:
    • 調用每個欄位的clean_<fieldname>() 方法(如果存在),以及調用表單的 clean() 方法
    • 調用每個欄位的 validate() 方法
    • 如果在上述任何步驟中發現錯誤,將錯誤信息添加到表單的 errors 屬性中
  • 如果沒有錯誤,is_valid() 返回 True,並且清理後的數據可以通過 cleaned_data 屬性訪問

上述的方法再確認表單資料沒有問題後,拿到各欄位的數據後就將其進行儲存,一個儲存過程就完成了

使用django.forms.ModelFrom建立表單

但是上面的寫法是不是有點冗長呢?

如果我們要儲存的就是Article表格,那能不能基於這個Model建立我們的表單呢?django.forms.ModelFrom 就是為此而設計的!

  1. 新增表單
# forms.py
from .models import Article

class ArticleModelForm(forms.ModelForm):
    class Meta:
        model = Article
        fields = ["title", "content", "category", "author", "tags"]

除了繼承的類別從forms.Form改成forms.ModelForm之外,我們也要在元數據中配置相對應的欄位

此外這邊也能設置多種參數來調整頁面或是相關邏輯

class ArticleModelForm(forms.ModelForm):
    class Meta:
        model = Article
        # fields = ["title", "content", "category", "author", "tags"]
        fields = "__all__"  # 這個是全部欄位
        # exclude = ["title"] # 這個是排除的欄位

        # 可以自訂義HTML顯示的標籤名稱
        labels = {
            "title": "標題",
            "content": "內容",
            "category": "分類",
            "author": "作者",
            "tags": "標籤",
        }

        # 可以自訂義HTML顯示的欄位樣式 後面再展開來說
        # widgets = {
        # }
				
        help_texts = {
            "title": "請輸入標題",
            "content": "請輸入內容",
            "category": "請選擇分類",
            "author": "請選擇作者",
            "tags": "請選擇標籤",
        }

        # 錯誤訊息
        error_messages = {
            # 可以統一設定所有欄位的錯誤訊息
            "__all__": {
                "required": "此欄位不能為空",
            },
            # 也可以針對特定欄位設定錯誤訊息
            "title": {
                "required": "標題不能為空",
                "max_length": "標題長度不能超過120個字",
            },
            "content": {
                "required": "內容不能為空",
            },
            "category": {
                "required": "分類不能為空",
            },
            "author": {
                "required": "作者不能為空",
            },
            "tags": {
                "required": "標籤不能為空",
            },
        }

至於像help_texts等在template可以這樣調用

{{ field.help_text }}
  1. 新增路由與視圖
# urls.py
urlpatterns = [
    ...
    path("demo_model_form/", demo_model_form_view, name="demo_model_form_view"),
]

# views.py
def demo_model_form_view(request):
    article_form = ArticleModelForm()
    error = None
    if request.method == "POST":
        article_form = ArticleModelForm(request.POST)
        if article_form.is_valid():
            article_form.save()
            return redirect("article:article_list_view")
        else:
            error = article_form.errors

    return render(request, "demo_form.html", locals())
    
# templates中的form表單要記得改action
<form action="{% url "article:demo_model_form_view" %}" method="post" enctype="multipart/form-data">

這樣我們就完成跟剛剛form表單一樣的功能了!程式碼是不是簡潔許多呢

了解forms.widgets的魅力,你看到的表單不是實際的表單

剛剛的需求都是相當單純的需求,雖然減少了程式碼量,但是為了這點功能而使用Django會不會有點大才小用?當然Django form能做的不只這點,讓我們做出以下情境:

  1. 我想要上傳圖片,但是我想要限制檔案在1MB以下以及在驗證過程存入指定路徑
  2. 如果我在輸入文章內容的時候,我想要用markdown語法輸入,儲存到資料庫時要使用HTML格式,方便最後網頁上的渲染,但是在編輯文章的時候我還是想看到markdown語法

我們透過處理以上情境,來徹底發揮Django form的潛力

  1. 建立新的表格,並且遷移
from django.db.models import JSONField

class ArticleV2(models.Model):
    article_id = models.AutoField(primary_key=True)
    title = models.CharField(
        max_length=120, verbose_name="Title", unique=True, null=False
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    category = models.ForeignKey("Category", on_delete=models.CASCADE)
    author = models.ForeignKey("Author", on_delete=models.CASCADE)
    tags = models.ManyToManyField("Tag")

    # 新增欄位
    cover = models.ImageField(upload_to="cover_image", null=True)
    # 修改欄位
    content = JSONField(default=dict)

    class Meta:
        app_label = "article"

在官方文檔提到,使用ImageField需要注意兩件事情:

  • 需要安裝Pillow來解析上傳的圖片
  • 需要確認表單能綁定上傳的檔案

https://docs.djangoproject.com/en/5.1/ref/forms/fields/

  1. 安裝Pillow
poetry add pillow
  1. 修改form表單
<form enctype="multipart/form-data" action="{% url "article:demo_model_form_view" %}" method="post">
  1. 那我們也要修改forms.py中的結構

首先我們需要做到的事情是要能讓圖片在上傳時能夠被檢查大小,這時候就可以用到validators

所謂的validators就是當違反設置好制定的邏輯,就會觸發ValidationError

我們先寫好指定的邏輯

from django.core.exceptions import ValidationError

def validate_file_size(value):
    filesize = value.size
    if filesize > 1 * 1024 * 1024:
        raise ValidationError("檔案大小不能超過1MB。")

然後將validators 套用進去

cover = forms.ImageField(
        validators=[
            FileExtensionValidator(["jpg", "jpeg", "png", "webp"]),
            validate_file_size,
        ],
        widget=forms.ClearableFileInput(attrs={"multiple": True}),
        required=False,
    )

那這邊的widget又是什麼?當你想要改變當前欄位在HTML上選染出來的input元素類型時,我們就可以使用widget 去改變原本forms預設好的input元素

而這邊使用ClearableFileInput而不是一般的FileInput,因為兩者都是代表<input type="file" ...> 之外,ClearableFileInput在有初始值且required=False時,可以提供額外的checkbox來清除檔案

  1. 接下來要處理markdown的部分

我們需要自定義一個欄位出來,需要有以下功能:

  • 定義prepare_value方法:當欄位有值的時候,要能將JSON轉成Markdown語法
  • 定義clean方法:除了繼承原本的clean方法之外,在這個階段就要將Markdown語法轉成JSON格式,不然會無法存入資料庫中

完整程式碼如下:

class JSONBMarkdownField(forms.CharField):
    def __init__(self, *args, **kwargs):
        kwargs["widget"] = forms.Textarea(attrs={"name": "content"})
        super().__init__(*args, **kwargs)

    def prepare_value(self, value):
        if value is None:
            return ""
        if isinstance(value, str):
            try:
                value = json.loads(value)
            except json.JSONDecodeError:
                return value
        return self.jsonb_to_markdown(value)

    def clean(self, value):
        value = super().clean(value)
        return self.markdown_to_jsonb(value)

    def jsonb_to_markdown(self, jsonb_data):
        markdown = ""
        for item in jsonb_data.get("content", []):
            if item["type"] == "paragraph":
                texts = item["text"].split("\n")
                for text in texts:
                    markdown += text + "\n"
            elif item["type"] in ["h1", "h2", "h3"]:
                markdown += f"{'#' * int(item['type'][1])} {item['text']}\n"
            elif item["type"] == "list":
                markdown += self.process_list(item)
            elif item["type"] == "image":
                markdown += f"![{item.get('alt', '')}]({item['src']})"
                if "width" in item:
                    markdown += f"{{width-{item['width']}}}"
                markdown += "\n"
            elif item["type"] == "table":
                markdown += self.process_table(item)
            elif item["type"] == "link":
                markdown += f"[{item['text']}]({item['url']})"
            elif item["type"] == "empty_line":
                markdown += "\n"
            elif item["type"] == "blockquote":
                markdown += f"> {item['text']}\n"

        return markdown.strip()

    def process_list(self, list_item):
        markdown = ""
        for index, item in enumerate(list_item["items"], start=1):
            prefix = "-" if list_item.get("style") == "unordered" else f"{index}."
            markdown += f"{prefix} {item['text']}\n"
            if item.get("description"):
                markdown += f"   {item['description']}\n"
        return markdown + "\n"

    def process_table(self, table):
        markdown = "| " + " | ".join(table["headers"]) + " |\n"
        markdown += "| " + " | ".join(["---"] * len(table["headers"])) + " |\n"
        for row in table["rows"]:
            markdown += (
                "| "
                + " | ".join(str(row.get(header, "")) for header in table["headers"])
                + " |\n"
            )
        return markdown + "\n"

    def markdown_to_jsonb(self, markdown):
        lines = markdown.split("\n")
        jsonb_content = []
        current_list = None
        current_table = None

        for line in lines:
            line = line.strip()
            if line.startswith("#"):
                level = len(line.split()[0])
                jsonb_content.append(
                    {"type": f"h{level}", "text": line.lstrip("#").strip()}
                )
                current_list = None
                current_table = None
            elif (
                line.startswith("- ")
                or re.match(r"^\d+\.", line)
                or line.startswith("* ")
            ):
                if current_list is None or (current_list["style"] == "ordered") != bool(
                    re.match(r"^\d+\.", line)
                ):
                    if current_list:
                        jsonb_content.append(current_list)
                    current_list = {
                        "type": "list",
                        "style": "unordered" if line.startswith("- ") else "ordered",
                        "items": [],
                    }
                text = re.sub(r"^-|\d+\.\s*", "", line).strip()
                current_list["items"].append({"text": text})
            elif line.startswith("!["):
                match = re.match(r"!\[([^\]]*)\]\(([^)]+)\)(\{width-(\d+)\})?", line)
                if match:
                    image = {
                        "type": "image",
                        "alt": match.group(1),
                        "src": match.group(2),
                    }
                    if match.group(4):
                        image["width"] = int(match.group(4))
                    jsonb_content.append(image)
                current_list = None
                current_table = None
            elif line.startswith("|"):
                if current_table is None:
                    current_table = {"type": "table", "headers": [], "rows": []}
                    jsonb_content.append(current_table)
                cells = [cell.strip() for cell in line.split("|")[1:-1]]
                if not current_table["headers"]:
                    current_table["headers"] = cells
                elif all(cell == "---" for cell in cells):
                    continue
                else:
                    row = {
                        header: cell
                        for header, cell in zip(current_table["headers"], cells)
                    }
                    current_table["rows"].append(row)
                current_list = None
            elif re.match(r"\[.*\]\(.*\)", line):
                match = re.match(r"\[(.*?)\]\((.*?)\)", line)
                link = {"type": "link", "text": match.group(1), "url": match.group(2)}
                jsonb_content.append(link)
                current_list = None
                current_table = None
            # 處理空行
            elif (
                not line
                and jsonb_content
                and jsonb_content[-1].get("type") != "empty_line"
            ):
                jsonb_content.append({"type": "empty_line"})
                current_list = None
                current_table = None
            elif line.startswith(">"):
                jsonb_content.append({"type": "blockquote", "text": line[1:].strip()})
                current_list = None
                current_table = None
            elif line:
                jsonb_content.append({"type": "paragraph", "text": line})
                current_list = None
                current_table = None
            else:
                if current_list:
                    jsonb_content.append(current_list)
                    current_list = None
                current_table = None

        if current_list:
            jsonb_content.append(current_list)

        return {"content": jsonb_content}

我們也想要修改上傳文章封面圖片的檔案名稱,希望上傳的檔名能夠改成統一格式,並且能包含文章的id,因此我們需要修改save方法

from django.core.files.uploadedfile import UploadedFile
from django.db import transaction

import os

def save(self, commit=True):
        article = super().save(commit=False)

        if commit:
            with transaction.atomic():
                article.save()
                self.save_m2m() # 當有ManyToMany欄位需要儲存時,這裡是tags

                # 獲取上傳的檔案
                cover = self.cleaned_data.get("cover")
                if cover and isinstance(cover, UploadedFile):
                    # 獲取原始檔案的擴展名
                    _, file_extension = os.path.splitext(cover.name)

                    # 創建新的檔案名,使用 article_id
                    new_file_name = f"article_{article.article_id}{file_extension}"

                    # 設置新的檔案名
                    article.cover.save(new_file_name, cover, save=True)

        return article

我們來看一下修改過後的ArticleModelForm

class ArticleModelForm(forms.ModelForm):

    cover = forms.ImageField(
        validators=[
            FileExtensionValidator(["jpg", "jpeg", "png", "webp"]),
            validate_file_size,
        ],
        widget=forms.ClearableFileInput(attrs={"multiple": True}),
        required=False,
    )

    class Meta:
        model = ArticleV2
        fields = [
            "title",
            "content",
            "category",
            "author",
            "tags",
            "cover",
        ]
        widgets = {
            "title": forms.TextInput(attrs={"class": "form-control"}),
            "category": forms.Select(attrs={"class": "form-control"}),
            "author": forms.Select(attrs={"class": "form-control"}),
            "tags": forms.SelectMultiple(attrs={"class": "form-control"}),
        }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["content"] = JSONBMarkdownField()
        self.fields["content"].label = "Content"
        self.fields["content"].initial = self.instance.content
        self.fields["content"].required = False

        self.fields["tags"].queryset = Tag.objects.all()
        self.fields["category"].queryset = Category.objects.all()
        self.fields["author"].queryset = Author.objects.all()

    def clean(self):
        cleaned_data = super().clean()
        return cleaned_data

    def save(self, commit=True):
        article = super().save(commit=False)

        if commit:
            with transaction.atomic():
                article.save()
                self.save_m2m()

                # 獲取上傳的文件
                cover = self.cleaned_data.get("cover")
                if cover and isinstance(cover, UploadedFile):
                    # 獲取原始文件的擴展名
                    _, file_extension = os.path.splitext(cover.name)

                    # 創建新的文件名,使用 article_id
                    new_file_name = f"article_{article.article_id}{file_extension}"

                    # 設置新的文件名
                    article.cover.save(new_file_name, cover, save=True)

        return article
  1. 視圖也修改一下

需要添加request.FILES

def demo_model_form_view(request):
    article_form = ArticleModelForm()
    error = None
    if request.method == "POST":
        article_form = ArticleModelForm(request.POST, request.FILES)
        if article_form.is_valid():
            article_form.save()
            return redirect("article:article_list_view")
        else:
            error = article_form.errors

    return render(request, "demo_form.html", locals())

並且我們也一起把article相關的View修改,方便在呈現詳細頁時,也是呈現表單的效果

def article_list_view(request):
    articles = ArticleV2.objects.all()
    return render(request, "article_list.html", {"articles": articles})

def article_detail_view(request, article_id):
    article = get_object_or_404(ArticleV2, article_id=article_id)
    article_form = ArticleModelForm(instance=article)
    return render(request, "article_detail.html", {"article_form": article_form})

Templates的部分

# article list
{% extends "base.html" %}
{% block content %}
<section class="article_list">
	<h1>Article List</h1>
	<ul>
		{% for article in articles %}
			<li>
				<a href="{% url 'article:article_detail_view' article.article_id %}">{{ article.title }}</a>
			</li>
		{% endfor %}
	</ul>
</section>
{% endblock content %}

# article detail 這邊就先不配置form提交的功能了
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
    <h2 class="mb-4">Edit Article</h2>
    <form method="post" enctype="multipart/form-data">
        {% csrf_token %}
        <div class="row">
            <div class="col-md-8">
                {% for field in article_form %}
                    <div class="mb-3">
                        {% if field.errors %}
                            <div class="alert alert-danger">
                                {{ field.errors }}
                            </div>
                        {% endif %}
                        <label for="{{ field.id_for_label }}" class="form-label">
                            {{ field.label }}
                        </label>
                        {% if field.field.widget.input_type == 'checkbox' %}
                            <div class="form-check">
                                {{ field }}
                                <label class="form-check-label" for="{{ field.id_for_label }}">
                                    {{ field.label }}
                                </label>
                            </div>
                        {% elif field.field.widget.input_type == 'select' %}
                            {{ field }}
                        {% else %}
                            {{ field }}
                        {% endif %}
                        {% if field.help_text %}
                            <small class="form-text text-muted">{{ field.help_text|safe }}</small>
                        {% endif %}
                    </div>
                {% endfor %}
            </div>
        </div>
        <div class="row mt-3">
            <div class="col">
                <button type="submit" class="btn btn-primary">Save</button>
            </div>
        </div>
    </form>
</div>
{% endblock content %}
  1. 此時我們隨便上傳一個超過1MB的檔案並且提交時,可以看到確實有返回錯誤資訊
    https://ithelp.ithome.com.tw/upload/images/20240918/201618668uSgHuqldM.png

並且你可以發現,雖然不是使用Ajax或是Js達成類似SPA的效果,頁面也重新整理過,但是你之前輸入的值都沒有消失!因為Django表單會自動保留輸入的資訊,雖然不包含上傳的檔案就是了,但是這樣已經相當方便了

  1. 最後我們填入相關的資訊,並且進行儲存
    https://ithelp.ithome.com.tw/upload/images/20240918/20161866SSXMZ3RrPL.png
    可以去資料庫確認一下content欄位,的確是JSON
{"content": [{"text": "title", "type": "h1"}, {"type": "empty_line"}, {"text": "sub title", "type": "h2"}, {"type": "empty_line"}, {"text": "content", "type": "paragraph"}]}

但是我們去文章詳細頁看,content還是維持住markdown語法
https://ithelp.ithome.com.tw/upload/images/20240918/20161866n61Vp1bRPz.png
現在有體驗到Django表單的一點魅力嗎?能夠在model制定好的基礎上,透過forms.widgets 很靈活的改變表單樣式,接著透過validators以及修改clean或是save方法,能夠自定義驗證的流程,能做出很大彈性的開發與修改流程

最後我知道這樣的頁面很辣眼睛哈哈哈,但是我們的目的是展示相關邏輯,加上準備的時間壓力

請先忽略這部分,完賽後我會再慢慢修改切版的部分。像這樣一篇文章從想好架構,確認資料,寫code與debug與寫文章可能還是要3-4小時左右XD,所以在頁面上先不要要求太多哈哈哈

今日總結

  • 透過forms.Formforms.ModelForm來達成兩種不同建立表單的方式,如果表單驗證邏輯跟清洗數據跟model實際要儲存的資料不會差異太大,透過後者可以更快的完成開發。但是如果是表單的資料需要儲存成不同的表格,那還是需要前者來做基礎資料的驗證,依照個人需求去做選擇
  • 透過forms.widgets 去彈性的呈現表單的input元素,並且利用自定義的欄位還有重新改寫clean與save方法,來達到多樣化的開發需求

目前為止,我們很多頁面都是要自己寫,有沒有一些預設的功能能夠讓我們更快速的完成CRUD呢?

就是Django Admin,明天我們將會進入Django預設的後台系統,來看看我們又能怎麼快速打造後台網站吧!

參考資料

Validators:https://docs.djangoproject.com/en/5.1/ref/validators/

Widget:https://docs.djangoproject.com/en/5.1/ref/forms/widgets/


上一篇
Django in 2024: 徹底玩轉Model,多資料庫開發與動態添加表格功能
下一篇
Django in 2024: 強大的Django Admin
系列文
Django 2024: 從入門到SaaS實戰31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言